Micron Document
baubs git

Node / mirrors / respira / commits / 5849a1e

Commit 5849a1e85429a2708320d18b7fbc2f7c7b5db128


Parents : 621de91
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-21T00:06:38+01:00

feature: Migrate ErrorPopover to shadcn Popover component

- Migrated ErrorPopover to use shadcn PopoverContent
- Removed forwardRef wrapper (handled by shadcn internally)
- Replaced custom absolute positioning with PopoverContent component
- Used cn() utility for cleaner className management
- Maintained all styling with info/danger color variants

- Updated AppHeader to use Popover pattern
- Replaced manual state management (showErrorPopover/setErrorPopover)
- Removed refs and click-outside detection useEffect
- Wrapped error button in Popover component with PopoverTrigger
- Simplified code by removing 30+ lines of manual popover handling

Benefits:
- Better keyboard navigation and accessibility (built into Radix UI)
- Automatic focus management and ARIA attributes
- Cleaner, more maintainable code
- Consistent with other shadcn components in the app

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Changes

2 files changed, 123 insertions(+), 150 deletions(-)


Diff

diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index 86eb809..0defd5c 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -1,9 +1,8 @@
-import { useRef, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
import { WorkflowStepper } from "./WorkflowStepper";
-import { ErrorPopover } from "./ErrorPopover";
+import { ErrorPopoverContent } from "./ErrorPopover";
import { getStateVisualInfo } from "../utils/machineStateHelpers";
import {
CheckCircleIcon,
@@ -15,6 +14,7 @@ import {
} from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
+import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function AppHeader() {
@@ -42,17 +42,12 @@ export function AppHeader() {
})),
);
- const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
+ const { pyodideError } = useUIStore(
useShallow((state) => ({
pyodideError: state.pyodideError,
- showErrorPopover: state.showErrorPopover,
- setErrorPopover: state.setErrorPopover,
})),
);
- const errorPopoverRef = useRef<HTMLDivElement>(null);
- const errorButtonRef = useRef<HTMLButtonElement>(null);
-
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
@@ -65,26 +60,6 @@ export function AppHeader() {
};
const StatusIcon = stateIcons[stateVisual.iconName];
- // Close error popover when clicking outside
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- errorPopoverRef.current &&
- !errorPopoverRef.current.contains(event.target as Node) &&
- errorButtonRef.current &&
- !errorButtonRef.current.contains(event.target as Node)
- ) {
- setErrorPopover(false);
- }
- };
-
- if (showErrorPopover) {
- document.addEventListener("mousedown", handleClickOutside);
- return () =>
- document.removeEventListener("mousedown", handleClickOutside);
- }
- }, [showErrorPopover, setErrorPopover]);
-
return (
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
@@ -157,64 +132,63 @@ export function AppHeader() {
)}
{/* Error indicator - always render to prevent layout shift */}
- <div className="relative">
- <Button
- ref={errorButtonRef}
- onClick={() => setErrorPopover(!showErrorPopover)}
- size="sm"
- variant="destructive"
- className={cn(
- "gap-1.5 flex-shrink-0",
- machineErrorMessage || pyodideError
- ? "animate-pulse hover:animate-none"
- : "invisible pointer-events-none",
- )}
- title="Click to view error details"
- aria-label="View error details"
- disabled={!(machineErrorMessage || pyodideError)}
- >
- <ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
- <span>
- {(() => {
- if (pyodideError) return "Python Error";
- if (isPairingError) return "Pairing Required";
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ size="sm"
+ variant="destructive"
+ className={cn(
+ "gap-1.5 flex-shrink-0",
+ machineErrorMessage || pyodideError
+ ? "animate-pulse hover:animate-none"
+ : "invisible pointer-events-none",
+ )}
+ title="Click to view error details"
+ aria-label="View error details"
+ disabled={!(machineErrorMessage || pyodideError)}
+ >
+ <ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
+ <span>
+ {(() => {
+ if (pyodideError) return "Python Error";
+ if (isPairingError) return "Pairing Required";
- const errorMsg = machineErrorMessage || "";
+ const errorMsg = machineErrorMessage || "";
- // Categorize by error message content
- if (
- errorMsg.toLowerCase().includes("bluetooth") ||
- errorMsg.toLowerCase().includes("connection")
- ) {
- return "Connection Error";
- }
- if (errorMsg.toLowerCase().includes("upload")) {
- return "Upload Error";
- }
- if (errorMsg.toLowerCase().includes("pattern")) {
- return "Pattern Error";
- }
- if (machineError !== undefined) {
- return `Machine Error`;
- }
+ // Categorize by error message content
+ if (
+ errorMsg.toLowerCase().includes("bluetooth") ||
+ errorMsg.toLowerCase().includes("connection")
+ ) {
+ return "Connection Error";
+ }
+ if (errorMsg.toLowerCase().includes("upload")) {
+ return "Upload Error";
+ }
+ if (errorMsg.toLowerCase().includes("pattern")) {
+ return "Pattern Error";
+ }
+ if (machineError !== undefined) {
+ return `Machine Error`;
+ }
- // Default fallback
- return "Error";
- })()}
- </span>
- </Button>
+ // Default fallback
+ return "Error";
+ })()}
+ </span>
+ </Button>
+ </PopoverTrigger>
- {/* Error popover */}
- {showErrorPopover && (machineErrorMessage || pyodideError) && (
- <ErrorPopover
- ref={errorPopoverRef}
+ {/* Error popover content */}
+ {(machineErrorMessage || pyodideError) && (
+ <ErrorPopoverContent
machineError={machineError}
isPairingError={isPairingError}
errorMessage={machineErrorMessage}
pyodideError={pyodideError}
/>
)}
- </div>
+ </Popover>
</div>
</div>
</div>

diff --git a/src/components/ErrorPopover.tsx b/src/components/ErrorPopover.tsx
index fd98541..17d11e4 100644
--- a/src/components/ErrorPopover.tsx
+++ b/src/components/ErrorPopover.tsx
@@ -1,95 +1,94 @@
-import { forwardRef } from "react";
import {
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/solid";
import { getErrorDetails } from "../utils/errorCodeHelpers";
+import { PopoverContent } from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
-interface ErrorPopoverProps {
+interface ErrorPopoverContentProps {
machineError?: number;
isPairingError: boolean;
errorMessage?: string | null;
pyodideError?: string | null;
}
-export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
- ({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
- const errorDetails = getErrorDetails(machineError);
- const isPairingErr = isPairingError;
- const errorMsg = pyodideError || errorMessage || "";
- const isInfo = isPairingErr || errorDetails?.isInformational;
+export function ErrorPopoverContent({
+ machineError,
+ isPairingError,
+ errorMessage,
+ pyodideError,
+}: ErrorPopoverContentProps) {
+ const errorDetails = getErrorDetails(machineError);
+ const isPairingErr = isPairingError;
+ const errorMsg = pyodideError || errorMessage || "";
+ const isInfo = isPairingErr || errorDetails?.isInformational;
- const bgColor = isInfo
- ? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
- : "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
+ const bgColor = isInfo
+ ? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
+ : "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
- const iconColor = isInfo
- ? "text-info-600 dark:text-info-400"
- : "text-danger-600 dark:text-danger-400";
+ const iconColor = isInfo
+ ? "text-info-600 dark:text-info-400"
+ : "text-danger-600 dark:text-danger-400";
- const textColor = isInfo
- ? "text-info-900 dark:text-info-200"
- : "text-danger-900 dark:text-danger-200";
+ const textColor = isInfo
+ ? "text-info-900 dark:text-info-200"
+ : "text-danger-900 dark:text-danger-200";
- const descColor = isInfo
- ? "text-info-800 dark:text-info-300"
- : "text-danger-800 dark:text-danger-300";
+ const descColor = isInfo
+ ? "text-info-800 dark:text-info-300"
+ : "text-danger-800 dark:text-danger-300";
- const listColor = isInfo
- ? "text-info-700 dark:text-info-300"
- : "text-danger-700 dark:text-danger-300";
+ const listColor = isInfo
+ ? "text-info-700 dark:text-info-300"
+ : "text-danger-700 dark:text-danger-300";
- const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
- const title =
- errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
+ const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
+ const title =
+ errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
- return (
- <div
- ref={ref}
- className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
- role="dialog"
- aria-label="Error details"
- >
- <div
- className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
- >
- <div className="flex items-start gap-3">
- <Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
- <div className="flex-1">
- <h3 className={`text-base font-semibold ${textColor} mb-2`}>
- {title}
- </h3>
- <p className={`text-sm ${descColor} mb-3`}>
- {errorDetails?.description || errorMsg}
- </p>
- {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
- <>
- <h4 className={`text-sm font-semibold ${textColor} mb-2`}>
- {isInfo ? "Steps:" : "How to Fix:"}
- </h4>
- <ol
- className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
- >
- {errorDetails.solutions.map((solution, index) => (
- <li key={index} className="pl-2">
- {solution}
- </li>
- ))}
- </ol>
- </>
- )}
- {machineError !== undefined && !errorDetails?.isInformational && (
- <p className={`text-xs ${descColor} mt-3 font-mono`}>
- Error Code: 0x
- {machineError.toString(16).toUpperCase().padStart(2, "0")}
- </p>
- )}
- </div>
- </div>
+ return (
+ <PopoverContent
+ className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
+ align="start"
+ >
+ <div className="flex items-start gap-3">
+ <Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
+ <div className="flex-1">
+ <h3 className={cn("text-base font-semibold mb-2", textColor)}>
+ {title}
+ </h3>
+ <p className={cn("text-sm mb-3", descColor)}>
+ {errorDetails?.description || errorMsg}
+ </p>
+ {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
+ <>
+ <h4 className={cn("text-sm font-semibold mb-2", textColor)}>
+ {isInfo ? "Steps:" : "How to Fix:"}
+ </h4>
+ <ol
+ className={cn(
+ "list-decimal list-inside text-sm space-y-1.5",
+ listColor,
+ )}
+ >
+ {errorDetails.solutions.map((solution, index) => (
+ <li key={index} className="pl-2">
+ {solution}
+ </li>
+ ))}
+ </ol>
+ </>
+ )}
+ {machineError !== undefined && !errorDetails?.isInformational && (
+ <p className={cn("text-xs mt-3 font-mono", descColor)}>
+ Error Code: 0x
+ {machineError.toString(16).toUpperCase().padStart(2, "0")}
+ </p>
+ )}
</div>
</div>
- );
- },
-);
-
-ErrorPopover.displayName = "ErrorPopover";
+ </PopoverContent>
+ );
+}

Served by rngit 1.3.3 - Generated in 0.09s